Skip to content

Conversation

@belleklaviyo
Copy link
Contributor

@belleklaviyo belleklaviyo commented Nov 26, 2025

Description

Easiest to review commit by commit but basically --

iOS does not have native support for dwell events. Instead, we can only go off of didEnter/didExit events and tracking potential dwell events via timers and do our best to catch if they have expired or not.

  • If a geofence has a duration on it, we start a timer for the given duration when the user triggers an enter event for that geofence. If the timer is still around when the timer expires, we fire a dwell event knowing an exit event has not occurred to cancel it.
  • If a user entered a geofence and exited it before a certain duration, then we cancel the dwell timer and know a dwell event has not occurred.

These timers are stored in memory.

Tricky part is, timers only run while the app is in the foreground. So, here is where we start doing a best-effort to catch expired dwell timers by also persisting their time stamps to UserDefaults

  • We check if any dwell timers have expired in the following cases when the app + network is available: foreground events, background events, and when a geofence event wakes up the app from a terminated state.
  • If any dwell timers have expired (their timestamp + duration has passed compared to the current time) when we check, then we fire a dwell event. In these cases, there will be a bit of a delay/inaccuracy of the event time as we only caught it when we could.

Due Diligence

  • I have tested this on a simulator or a physical device.
  • I have added sufficient unit/integration tests of my changes.
  • I have adjusted or added new test cases to team test docs, if applicable.
  • I am confident these changes are compatible with all iOS and XCode versions the SDK currently supports.

Release/Versioning Considerations

  • Patch Contains internal changes or backwards-compatible bug fixes.
  • Minor Contains changes to the public API.
  • Major Contains breaking changes.
  • Contains readme or migration guide changes.
    • If so, please merge to a feature branch so documentation updates only go live upon official release.
  • This is planned work for an upcoming release.
    • If no, author or reviewer should account for this in a release plan, or describe why not below.

Changelog / Code Overview

Test Plan

I used a mocked response for GET client/geofences in this commit to test getting a geofence response with durations. I then tested all of the following scenarios and some other variants:

  1. App was foregrounded when enter event happened, then waited the expected duration, and then saw a dwell event fire.
  2. App was foregrounded when enter event happened and backgrounded halfway through duration period. App was in background for remainder of duration period (and a bit beyond). Dwell event did not fire automatically. Upon reforegrounding, though, dwell event fired.
  3. App was backgrounded when enter event happened and terminated halfway through duration period. App was in terminated for remainder of duration period (and a bit beyond). Location was changed "significantly" while app still terminated (waking up app). Dwell event fired without app being opened.

Related Issues/Tickets

https://klaviyo.atlassian.net/browse/CHNL-28037

@belleklaviyo belleklaviyo marked this pull request as ready for review December 2, 2025 20:59
@belleklaviyo belleklaviyo requested review from a team as code owners December 2, 2025 20:59
@belleklaviyo belleklaviyo removed the request for review from kennyklaviyo December 2, 2025 21:05
@ajaysubra
Copy link
Contributor

App was foregrounded when enter event happened and backgrounded halfway through duration period. App was in background for remainder of duration period (and a bit beyond). Dwell event did not fire automatically. Upon reforegrounding, though, dwell event fired.

Firstly, thanks for the detailed notes on the PR. Super useful for reviewing. If we should fire the dwell event in the future seemed to me like a product question so checking in with PMs.

Copy link
Contributor

@ab1470 ab1470 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good, although it seems like maybe we're not going to merge this?

/// Optional duration for this geofence to record a dwell event
var duration: Int? {
let components = id.split(separator: ":", omittingEmptySubsequences: false)
guard components.count == 4, components[0] == "_k" else { return nil }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

didn't we have an isKlaviyo... method somewhere to check if something starts with _k (and therefore is a Klaviyo event/notification)?

Comment on lines 154 to 157
geofenceDwellSettings.removeAll()
for geofence in geofences {
geofenceDwellSettings[geofence.locationId] = geofence.duration
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code will remove all geofence dwell settings, including those that may not have been included in the Set that the caller supplied when they called this method. Is that okay?

Comment on lines 192 to 194
if #available(iOS 14.0, *) {
Logger.geoservices.info("🕐 Fired expired dwell event for region \(geofenceId) (expired while app was terminated)")
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should go inside the Task, below the full await clause. Technically the current order is incorrect, as you're dispatching an async task that may or may not complete, and then you're immediately logging the event before waiting for the task to finish

// Check if timer expired (elapsed >= duration)
if age >= TimeInterval(timerData.duration) {
timersToRemove.append(geofenceId)
if currentTime - timerData.startTime >= TimeInterval(timerData.duration) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you create a private helper function (or computed property) to abstract this logic and make it more readable? Something like:

if timerData.isExpired {
    ...
}

@belleklaviyo
Copy link
Contributor Author

belleklaviyo commented Dec 5, 2025

Closing for now as we have decided due to iOS's limitations of not natively supporting dwell and consequently requiring intrusive/overly complex workarounds to even provide a best effort level of support, we will not support it for this milestone. We may revisit later and/or include some of these model changes, so we could flexibly support it in the future and be backwards compatible. If we decide to do those model changes/more incremental support, we will do those in new PRs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants